Statics defrag, map region tools and UOP client fixes - #179
Merged
Merged
Conversation
Map.DefragStatics wrapped the rewrite in a bare catch that, on any error, mutated its loop variables and zero-filled every remaining block. It never checked that a block length was a whole number of records or that its lookup was inside the file, kept out-of-block offsets and z == -128, and keyed duplicates on hue so stacked water survived. Ultima/Statics replaces it: every filter explicit, every anomaly a counter on the result rather than control flow. StaticsComparer reads a run back and compares each block as a multiset, so an unfiltered run must come back identical and a filtered one may only be missing what the counters name. Map.DefragStatics stays as an obsolete shim so plugins keep compiling.
The two menu items ran on the UI thread under a wait cursor, offered no choice beyond duplicate removal, and always reported success - including when the source files were missing and nothing had been written. Both are replaced by one entry opening MapDefragStaticsForm: each filter a checkbox, the resolved item id ceiling shown, the block grid compared with the index actually on disk, and the run on a background worker with a working Cancel. The result form saves or copies the report, and its Verify button reads both files back and reconciles every static against the filters.
TileMatrix allocated InvalidLandBlock as Tile[196]. 196 is the on-disk byte size of a block, not its 64 tiles, so a consumer that enumerates the block wrote 588 bytes instead of 192 and misaligned every block after it. TileMatrixPatch took _blockHeight from BlockWidth, so on TerMur - the only facet taller than it is wide - every patched block from y 160 up was reported unpatched and its diff data dropped. Files registered the mapdif and stadif families for indices 0 to 4 only, so facet 5 diffs could never be resolved at all.
The only code in the tree that could write a UOP container lived in the UopPacker plugin, which nothing in the app can reference, so the map tools could read a UOP map and then only ever write a mul back. LegacyMulFileConverter and its helpers move to Ultima/Uop, the container framing splits out into UopContainerWriter, and the entry-table walk that was private to TileMatrix becomes MapUopReader. MapSizes measures a facet shape from the files rather than a hardcoded table, and MapUopWriter and the block sinks let a caller emit a map one block at a time as either container. Packing is byte-identical before and after.
The read-validate-filter-write loop over a staidx/statics pair existed in five places, and only the defragmenter copy checked block lengths, lookup bounds and out-of-block offsets. The three forms still keyed duplicates on hue, which is what let stacked water survive. StaticsBlockIo holds those pieces on their own - index reader, block writer, per-static filter and a shared counter interface - and the defragmenter is rewired onto them, keeping only what is its own.
Both features lived in button handlers: raw streams without using, no bounds
check on the computed block, and a progress bar stepped once per block on
the UI thread. MapDiffInsertForm also looked up map{N}.mul and nothing else,
so it refused outright on a client whose maps are UOP-only.
MapRegionCopier and MapDiffApplier take the work over and read through
TileMatrix, so both run on a modern client. Land keeps its 4-byte block
header instead of having it rewritten as zero, which makes an identity copy
byte-exact. The dialogs get exclusive bounds, normalised rectangles, a
background worker with a working Cancel, and a result form that reads the
output back and verifies it.
Both dialogs described the region only as coordinates, which says where it is but nothing about what is there. MapRegionPreview draws a window of a facet with the block-aligned region outlined on it, and the region can be dragged. It keeps a view of its own, an origin and a span in blocks, so the left button changes only the selection, the right button pans and the wheel zooms about the block under the pointer. Rendering goes through the existing Map.GetImage family on a worker behind a debounce. Reading a land block past the end of a short map file now returns empty rather than throwing, which is what the statics side already does and what clicking around the preview needs.
A region taken from one facet rarely sits at the right height in another. A Z adjust beside the paste position shifts the land and the statics of the region by the same amount and touches nothing outside it. Both heights are a signed byte, so the dialog surveys the region on a worker and says what the shift makes of it, and the copy refuses one that does not fit, naming how far the region can actually move. A checkbox clamps at the limit instead, and verification compares against the source shifted by the same amount. The diff dialog deliberately has none of this: its records are cut against the facet they belong to.
Art, gumpart, sound, MultiCollection and maps all ship in either container, but only the map tools ever offered the choice, so saving on a modern client produced files that client does not read. SaveFormat is a per-profile option - FollowSource, Mul, Uop or Ask - answered per file, since a client can be UOP art and MUL gumps. ClientFileSaver writes the mul into a temporary folder and packs it, so no domain class learns about UOP. MultiCollection needs the client housing.bin and the per-tile component ids that live only in the UOP, so it unpacks the loaded container first and refuses when there is none.
An unused idx row carries lookup 0 and length -1, and -1 has the same high bit that marks a verdata patch, so every one of those rows looked like a patch of length 0x7FFFFFFF. Browsing the textures of a 2005 client then read that many bytes out of Stream.Null and threw from a paint handler. The bit test now also requires the length not to be -1; a real patch never reaches 0x7FFFFFFF, so the two cannot be confused. Gumps had its own copy of the test. Textures.GetTexture also sized its read from the entry 64/128 flag without checking the declared length, so a truncated file over-read the same way.
Giving a set of items the same flags meant exporting to CSV, editing it elsewhere and importing it back. Both tiledata lists are now multi-select and the editor pane gains a leave-this-alone state: it blanks every box the selection disagrees on, greys every flag that is not uniformly set or clear, and Save Changes writes only what was actually edited. Also adds Copy tile data and Paste special, and a one-level undo of the last bulk apply, both through one apply engine. Fixes a bug in the code this replaces: OnFlagItemCheckLandTiles mapped the checkbox index through a stale switch, so toggling a land flag flipped the wrong bit.
Getting a graphic in or out of the Items, Land Tiles, Gumps or Textures tab meant a file dialog either way. All four tabs now copy and paste images: copy puts both a DIB and a PNG on the clipboard, PNG being the only flavour that carries alpha, and paste reads PNG first, then the DIB, then a file. An image with a real alpha channel is taken at its word; one without still goes through ConvertBmp, so paste and Replace agree. Wrong sizes are refused rather than fitted. The second half is telling afterwards what changed. Art, Gumps and Textures now mark every Replace and Remove, clear the marks on reload and after save, and the draw handlers paint a corner wedge over a marked entry.
The hued GetGump overload was the last caller of the legacy FileIndex.Seek, so on a client with gumpartlegacymul.uop it read the compressed payload as raw RLE, derived garbage row offsets and walked the pixel pointer off the buffer. That surfaces as an AccessViolationException, which takes the process down rather than reaching the exception handler. It now decodes through the regular GetGump path, which already covers the index, removed, replaced and decompression checks, and hues a private clone. That retires a static pixel buffer the old decoder returned a Bitmap over, which the following call would overwrite. IsValidIndex and GetGump also guard against a negative index.
ParseSequenceEntry walked each body group records with a fixed 72 byte stride and skipped the walk outright when the group count was 48 or 68. Both are wrong, and they hide each other: 72 bytes is only the record with both trailing lists empty, and 48 and 68 are the counts of the bodies whose records are not. A record is 64 bytes of fixed fields followed by a counted list of 32 byte property records and a counted list of ints, each count checked against the bytes remaining before it is used. On 7.0.114.4 the bodies with aliases go from 383 to 385: body 666 gains 67->66, and body 1253 gains 2->0, 3->1 and 42->38.
Bump UoFiddler to 4.24.0 and add the change log entry for this release: the statics defrag rewrite, the map copy and diff work with the region preview, the per-profile save format, and the texture, gump and AnimationSequence fixes.
This was referenced Sep 13, 2026
Closed
AsYlum-
marked this pull request as ready for review
September 13, 2026 12:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Added
Statics defrag — rewritten around an explicit set of filters: item id ceiling, out-of-block offsets, invalid z, negative hue, statics buried under terrain, duplicates and stacked statics. Every run produces a report naming what was removed instead of dropping tiles silently, runs on a background worker with a working Cancel, and offers a Verify pass that reads the result back and reconciles it against the source. The two blind menu items are replaced by one options dialog.
Map region copy and diff insert — both now run on clients whose maps are UOP-only; the diff tool previously refused outright. An identity copy is byte-exact, output can be mul or uop, and both dialogs gained a draggable region preview with pan and zoom, so a region is picked on the map rather than typed as coordinates. A copied region can also be shifted in z, with the dialog saying up front how far it can move before the signed byte runs out.
Save format — a per-profile option for whether art, gumpart, sound, MultiCollection and maps are written as mul or uop, following the loaded client or chosen at save time. Saving on a modern client no longer produces files that client cannot read.
TileData bulk editing — both lists are multi-select, with a leave-this-alone state for fields the selection disagrees on, Copy tile data / Paste special, and a one-level undo of the last bulk apply.
Clipboard art — Items, Land Tiles, Gumps and Textures copy and paste images directly, alpha preserved, and mark the entries edited since load.
Fixed